Skip to content

feat(canvas): 完善旧版 CanvasContext 跨端能力并统一触摸手势与 label 激活 - #306

Open
lbb00 wants to merge 1 commit into
didi:mainfrom
EchoTechFE:worktree-canvas-peripherals-pr
Open

feat(canvas): 完善旧版 CanvasContext 跨端能力并统一触摸手势与 label 激活#306
lbb00 wants to merge 1 commit into
didi:mainfrom
EchoTechFE:worktree-canvas-peripherals-pr

Conversation

@lbb00

@lbb00 lbb00 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

这个 PR 做了什么

补齐旧版 wx.createCanvasContext() 在 Android、iOS、HarmonyOS、Web 四端的能力,顺带把 canvas 会用到的触摸事件和 label 点击对齐微信的行为。新老编译包都能跑。

Canvas

  • 旧版绘图 API 四端补齐:画线填充、save / restore、图片加载、像素读写、导出 PNG / JPEG,跨批次的状态也对齐了微信。
  • canvas-id 按页面和自定义组件实例隔离,同名 canvas 不会互相串;动态改 canvas-idtype 会正确重新绑定。
  • Canvas node(Canvas 2D / WebGL / 小游戏)补齐 ImageData、requestAnimationFrame、图片、2D 状态回读和上屏 / 离屏的生命周期。
  • 同一个 canvas 上的绘制、读像素、导出保持先后顺序,不同 canvas 互不阻塞。
  • 加了尺寸和内存上限(单边 4096 px、位图 32 MiB),超限在分配位图之前就失败,不会先把内存吃掉再崩。
  • 导出任务跟着 app 走:退出或重启会取消还没开始的任务,已经生成但送不到当前 runtime 的文件会删掉,旧回调不会串到新页面。

触摸和 label

  • taplongpresslongtapcanceltap 不再直接拿浏览器的 click 当小程序 tap,改成从触摸序列合成——这样 catchtap 才能真正拦住外层的 bindtap。程序化点击、键盘和无障碍触发仍然走 click 兜底。
  • 手指移动超过阈值会发 canceltap;没有 touchmove 时(被 preventDefault 吞掉,或系统把移动并进了抬手)在 touchend 再按起点终点复查一次。这两条都是按微信实测对齐的。
  • 自定义组件的宿主节点也走同一条链路,否则它的 catchtap 会排在祖先合成的 tap 之后,拦不住祖先。
  • label 点到控件时直接激活对应控件,不再调 target.click() 产生第二次 tap;容器事件的 currentTargetiddataset 归到真正绑定事件的那个节点。
  • 修了组件化之后 canvas 触摸坐标偏 4 px 的问题。

这次没做

  • capture-bind / capture-catchmut-bind 还没实现,本 PR 不碰捕获阶段。
  • 多指同时按下时 tap 的时机微信没有公开定义,这里只让建立序列的第一根手指产生 tap,不采用 glass-easel 按 identifier 逐触点记账的模型。

验证

分支已 rebase 到 main 最新的 2d7b9ea,冲突只有两处新增用例登记,按并集解决。以下都是在 b6b6fce 上实际跑出来的:

  • FE:224 个测试文件、1737 个用例全部通过,pnpm lint、production build、git diff --check 通过。
  • Android 266 个用例、iOS 287 个用例、HarmonyOS 203 个用例通过,0 失败 0 skip。
  • Android / iOS / HarmonyOS 模拟器上跑完整的 legacy canvas 页面 59 / 59,包含像素读写、导出后立即读文件和视觉检查。
  • JSSDK 由 pnpm generate:sdk 从 production dist 重新生成,包内 service.jspageFrame.js 与 container dist 逐字节一致。

设计细节见 Canvas 运行架构触摸事件与手势

@lbb00
lbb00 force-pushed the worktree-canvas-peripherals-pr branch from c16375e to 9dd5b29 Compare August 13, 2026 15:04
@lbb00 lbb00 changed the title feat(canvas): complete legacy CanvasContext peripherals feat(canvas): 完善旧版 CanvasContext 跨端能力 Aug 13, 2026
@lbb00 lbb00 changed the title feat(canvas): 完善旧版 CanvasContext 跨端能力 feat(canvas): 完善微信小程序旧版 CanvasContext 跨端能力 Aug 13, 2026
@lbb00
lbb00 marked this pull request as ready for review August 14, 2026 06:38
@lbb00

lbb00 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

@dos1in review

Comment thread fe/packages/components/src/component/label/Label.vue
Comment thread fe/packages/components/src/component/label/Label.vue
@lbb00
lbb00 marked this pull request as draft August 17, 2026 11:50
@lbb00 lbb00 changed the title feat(canvas): 完善微信小程序旧版 CanvasContext 跨端能力 feat(canvas): 完善旧版 CanvasContext 跨端能力并统一触摸手势与 label 激活 Aug 17, 2026
@lbb00
lbb00 force-pushed the worktree-canvas-peripherals-pr branch 2 times, most recently from 35f9a8d to d58e3fe Compare August 18, 2026 09:11
@lbb00
lbb00 marked this pull request as ready for review August 18, 2026 09:21
@lbb00

lbb00 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@dos1in 本来是想修改 Canvas 上的小问题,这下干脆把整个 Tap 事件按照微信小程序对齐了一遍。可以再 review 了

@dos1in

dos1in commented Aug 18, 2026

Copy link
Copy Markdown
Member
  1. [P1] canvas-id 的组件作用域被整页判重破坏

Canvas.vue#L49-L64

渲染层已经按 moduleId 支持不同组件作用域使用相同 canvas-id,并新增了对应测试;但这里仍在整个 .dd-page 内判重。两个相同自定义组件实例都包含 canvas-id="chart" 时,两边都会进入 isError 并被隐藏,组件作用域隔离实际上无法使用。请按组件实例作用域判重或移除整页判重,并增加通过真实 Canvas.vue 挂载两个组件实例的回归测试;当前 render 测试直接构造 DOM,绕过了这个守卫。

  1. [P1] 动态切换 bind/catch 会中断正在进行的手势

useTouchEvents.js#L26-L52
components/index.js#L221-L243

touchstart 处理器可能修改响应式事件绑定,例如动态启用 catchtouchmove。如果更新在 touchend 前到达,install()/updateCanvasGestures() 会立即 detach() 当前 owner,清掉活动序列、长按计时器和 tap 状态;新 owner 只看到 touchend,无法再生成本次 tap/longpress/canceltap。这与文档宣称支持动态切换相冲突。建议把监听器重配延迟到当前序列的 touchend/touchcancel 后,并覆盖组件路径和原生 canvas 指令路径。

  1. [P1] iOS 在主线程解码并写入最大 32 MB 图片

ImageAPI.swift#L120-L170

这条调用来自 WKScriptMessageHandler,当前同步执行 Base64 解码、签名校验、目录 I/O 和 atomic write,最大允许 32 MB。大画布导出会长时间占用 iOS 主线程,造成明显卡顿甚至 watchdog 风险。虽然返回类型是 DMPAsyncResult,实际工作仍在返回前完成。请把解码和文件写入移到后台队列,再通过原有 callback 单次回传结果。

  1. [P2] save/restore 的逻辑状态与微信 3.16.2 不一致

canvas-context.js#L288-L295

save() 需要改为使用 drawingState.push(this.state),不会克隆对象;本实现改为 cloneState(),测试也明确写了 “unlike the official implementation”。因此 save → setFontSize/setLineDash → restore 后,fontgetLineDash()measureText() 会与微信返回不同结果,应保持微信小程序实测语义。

@lbb00

lbb00 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@dos1in 四条都改了,在 99db340

1. [P1] canvas-id 的组件作用域被整页判重破坏

判重改为按「页面 + 宿主组件实例 + canvas-id」在模块作用域的 Set 里登记(${info.bridgeId}|${info.moduleId}|${props.canvasId}),跟渲染层 getCanvasElement 的 moduleId 作用域对齐,整页 DOM 查询去掉了。出错的实例从不登记,因此 onUnmounted 只在登记成功时才归还 key,否则后来的重复实例卸载时会把先到者的登记删掉。

新增 fe/packages/components/__tests__/canvas-id-scope.spec.js(4 例),走真实 Canvas.vue 挂载:两个同组件实例各持一份同名 canvas-id 都可用;同一实例内重复仍报错且第一块保持可用;跨页面同 moduleId 互不影响;卸载归还的时机。

2. [P1] 动态切换 bind/catch 会中断正在进行的手势

重装监听器这件事保留(passive 只能在注册时决定),但推迟到当前序列以 touchend/touchcancel 收口之后。attachTouchEventsdetach() 新增 onDetached 回调,在监听器真正摘干净、__ddGestureDetach 让出之后才触发;finishPendingDetach 改为把收尾排进 enqueueAfterTouchEnd 的同一个微任务队列里,排在终态 tap 的派发之后——否则提前推进 generation 正好吃掉这一次的 tap。组件路径(useTouchEventsonUpdated)和原生 canvas 指令路径(components/index.jsswapCanvasGestures)走同一条逻辑;推迟期间到达的属性更新只更新 latestVNode,换 owner 时按最新的一份重新解析。

新增 fe/packages/components/__tests__/gesture-reinstall-during-sequence.spec.js(4 例):两条路径各自在 touchstart 里打开 catchtouchmove,tap 仍照发且重装发生在 touchend 之后;序列之间切换立即重装;一次序列内切换两次装的是最后一份。

3. [P1] iOS 在主线程解码并写入最大 32 MB 图片

解码 + 落盘抽成 ImageAPI.writeCanvasTempFile,放到 DispatchQueue.global(qos: .userInitiated),回主线程走原有 callback 单次回传。参数校验很便宜,留在原地保持同步失败语义。

同一个问题另外两端也有,一并改了(这两条不在 review 列表里):

  • Android handleAction 跑在主线程(JsCore 把 QuickJS 的每次 evaluate 都 post 到主 Looper),改为独立的 CoroutineScope(SupervisorJob() + Dispatchers.IO)。没用 activity.lifecycleScope:每个页面是自己的 DiminaActivity 而 QuickJS 按 appId 共享,用户在写盘途中返回上一页会取消协程,success/fail/complete 一个都不发,等 complete 的小程序永远挂住。回调切回主线程后由新增的 dispatchCanvasResult 按同步路径的契约派发(:ok 结尾走 success,否则 fail,complete 无论如何都发且带同一个 result)。
  • HarmonyOS saveCanvasTempFile 由 render 层发起、走主线程的模块表(worker 上只注册了 Http/Storage/File),改成 Base64Helperfs 的 Promise 接口。顺带两处:解码结果不保证独占整个 ArrayBuffer,而 fs.write 收的是整个 buffer,带偏移时会把视图之外的字节也写进去,改为只在确实带偏移时 slice();方法变成 async 之后 bridge 那层只接同步抛错的 try/catch 不再兜得住它,失败通知自己包了一层,避免变成没有日志的 unhandled rejection。

Android 新增 5 例、iOS 新增 3 例单测,覆盖真实写盘产物、签名不匹配时不落盘、success/fail 各自的 complete 载荷,以及 iOS 的重活确实不在调用线程上完成。

4. [P2] save/restore 的逻辑状态与微信不一致

save() 改回 drawingState.push(this.state)cloneState() 删掉。两条断言按微信实测语义反过来了:save → setFontSize → restore 之后 font 停在 24px sans-serifsave → setLineDash → restore 之后 getLineDash() 停在后来的值。

还有一处同源的偏离没动,想听下你的意见:微信的 setLineDash 存的是调用方传进来的那个数组本身,而不是拷贝——调用方之后修改这个数组,会穿透到 getLineDash() 的返回值。我们这边是 [...pattern],防御性拷贝。要完全对齐的话这条也该跟着改。


验证:

  • fepnpm test 全绿(components 16 文件 173 例、service 50 文件 666 例,含上面两个新 spec),pnpm lint 0 warning。
  • Android:./gradlew testDebugUnitTest 245 例 0 失败。
  • iOS:xcodebuild test 222 例 0 失败。
  • 真机/模拟器回归:canvas 59 例、label 8 例在 Android(实机)、iOS 模拟器、HarmonyOS 模拟器上各自全过。canvas 用例里包含改后的 save/restore 断言,三端都确认执行到了而不是被跳过。

@dos1in

dos1in commented Aug 19, 2026

Copy link
Copy Markdown
Member
  1. [P1] 动态修改 canvas-id 会让判重表失效
    Canvas.vue#L56-L81
    claimedCanvasIds 只在 onMounted 时登记、onUnmounted 时释放,但 props.canvasId 可以动态变化,模板中的真实 canvas-id 也会随之更新。
    例如第一块 canvas 从 before 改成 after 后:
  • before 仍被错误占用,新的 before canvas 会被隐藏。
  • after 没有登记,新的 after canvas 反而能同时显示。
    我添加了两条临时回归用例,分别验证“释放旧 ID”和“占用新 ID”,两条均稳定失败。建议监听 canvasId,在变化时原子地释放旧 key、校验并登记新 key,同时补真实 Canvas.vue 动态属性测试。
  1. [P1] HarmonyOS 的旧异步回调会穿透到重启后的新 JS Service
    Canvas bridge#L131-L177
    DMPBridges.ets#L56-L90
    DMPChannelProxyNext.ets#L106-L113
    saveCanvasTempFile 改为异步后,回调只保存了 appIndex。restartMiniProgram 会递增 runtimeGeneration 并替换 service/container,但仍复用相同的 DMPApp 和 appIndex。
    因此旧 runtime 发起的解码/写盘若在 restart 后完成,ContainerToService 会通过 appIndex 找到新 service,把旧 callback ID 投递进新 JS runtime,可能误触发同 ID 的新回调。
    建议创建任务时捕获 app 身份和 runtimeGeneration,回调前确认两者仍有效;失效时只清理临时文件,不再通知新 runtime,并补 restart/exit 与写盘并发的回归测试。

  2. [P1] 三端异步改造没有并发上限,32 MB 安全阈值可被并发绕过
    Android ImageApi.kt#L84-L95
    Android ImageApi.kt#L442-L458
    iOS ImageAPI.swift#L173-L202
    HarmonyOS Canvas bridge#L131-L177
    Android 的全局 IO scope、iOS 的 global queue 和 HarmonyOS 的独立 Promise 都没有 per-app/global 并发限制。
    单个上限请求会同时保留约 44 MB Base64 字符串、最多 32 MB 解码结果以及写盘缓冲;不同 canvas 并发几次即可达到数百 MB,导致移动端 OOM。当前 32 MB 上限只约束单次请求,不能实现注释所说的内存安全边界。
    建议按 app/runtime 使用串行或有界队列并提供背压,runtime 销毁时取消或失效其任务,并增加并发大图导出的峰值任务数及单次回调测试。

  3. [P2] setLineDash 建议继续对齐微信 3.16.2
    canvas-context.js#L331-L346
    3.16.2 保存的是调用方传入的数组引用;当前 [...pattern] 做了防御性复制。因此调用后再修改原数组时,getLineDash() 的结果不同。这里也建议一并对齐并补测试。

@lbb00

lbb00 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

第二轮四条已修(6f4e26e),修的过程中从这批改动本身又挖出五条,一并修掉。

你提的四条

  1. 动态改 canvas-id 让判重表失效 — 已修。只加 watch 不够:canvas-id 的 watch 是 pre-flush、onUnmounted 是 post-flush,同一次更新里「一方让出、另一方接手」时接手方先跑、看到的是旧表,被拒后没有任何路径重新评估,结论还会随模板书写顺序反转。改成判重表存 owner,让出时就地重试被挡下的实例,冲突 error 延到本次 flush 结算完再报。
  2. Harmony 旧回调穿透到重启后的新 service — 按你的建议加了 generation 比对。另两端逐个读过代码:Android 的 callback 闭包捕获具体 JsCore 实例、退出即换新对象(Bridge.kt:419RuntimeMessageQueue.kt:13-17);iOS 弱捕获 DMPContainer 且重启置 nil。这条只在 Harmony 成立。
  3. 异步改造没有并发上限 — 三端都做了按 appId 串行。这里更正我上一轮注释的一句过度声称:串行只把解码与写盘压回一次一个,排队中的请求仍各自持有自己那份 base64,注释已改。没加硬的深度上限——那会把「循环导出 10 张」变成失败,而排队项的主要占用在调用方那边已经生成了。你更想要背压的话我可以补。
  4. setLineDash 对齐微信 — 已改成保持调用方数组引用。它顺手打破了非有限数守卫(原本靠录制时深拷贝间接成立),守卫已下沉到 getActions() 这个唯一出口。

自查另外发现的五条

  • 页面作用域会静默画到组件的 canvas 上:判重改成按 moduleId 后同名 canvas 可合法共存,而 getCanvasElement 页面作用域直接按文档序取第一个、不校验归属。现在元素带 data-canvas-owner,先按归属精确命中,再回退。
  • Harmony 的 saveCanvasTempFile 不该是 async:它是 javaScriptProxy 的同步返回值,async 会让返回值变成 pending Promise 穿过代理,校验段抛错也会变成无日志的 unhandled rejection。已改回同步入口。
  • Harmony 写盘队列作用域错了:挂在模块实例上,而模块图随 runtime 重建。改成按 appId 的静态链。
  • 成功已交付后派发再抛错会删掉那个文件:结算混在管理文件生命周期的 try 里。已移出且只做一次。
  • Android 派发抛错从「转 fail」退化成「崩进程」:挪进协程后没有 handler。已补 CoroutineExceptionHandler

已知未修

runtime 在导出途中销毁时,Android/iOS 会把已写成的临时文件留在 tmp(回调本身正确丢弃)。Harmony 有 generation 可以顺手删,另两端没有等价 epoch,要检测得改 JsCore.postMessage 返回契约或 iOS 的 container 持有关系,都在本 PR 层级之外;且三端 tmp 对任何 API 都没有清扫,只给 canvas 加会变成特判。建议单独做「app 启动清扫 tmp」。这是我把写盘异步化打开的窗口,不是存量问题。

其余:单次文件 IO 无 deadline;Android/iOS 的 appId→锁表只增不减;iOS 的陈旧判定押在「DMPAppDMPContainer 唯一强持有者」这条隐式 ARC 不变量上。

验证

结果
fe 单测 1610 例全绿(compiler 325 / service 667 / components 181 / render 137 / container-sdk 276 / common 16 / container 8)
Android 248 例 0 失败;解包产物确认新增 handler 在内
iOS diminaTests 47 + DiminaKitTests 222,0 失败
HarmonyOS 删 build 目录冷编译;解包 hap 确认本轮代码在 ets/modules.abc(hvigor 增量会静默漏掉新增方法)
三端真机 label 8/8、旧版 canvas 59/59、canvas 压力 20/20,各 × Android 真机 / iOS 26.5 / HarmonyOS

新增行为测试都做了变异验证(去掉修复必须变红):让出重试 3 条、归属解析 2 条、getActions 守卫 1 条。

真机第一轮有两条红,都查了归属:setLineDash 那条是我们 harness 的断言写的是语义(注释里还写着 "deliberate deviation"),按微信实测行为改了断言;Harmony 的「draw() 不因不可达图片挂起」超了用例 20s 上限(渲染层自身超时 10s),重跑通过、此前 8 轮也通过,根因未查明

@dos1in

dos1in commented Aug 19, 2026

Copy link
Copy Markdown
Member
  1. [P1] 动态切换 Canvas type 会再次破坏 canvas-id 登记
    Canvas.vue#L116-L124
    登记逻辑只监听 canvasId,但是否参与旧版 canvas-id 判重还取决于 props.type:
  • 旧版 canvas 切换成 type="2d" 后,原 ID 仍被占用。
  • type="2d" 切换成旧版后,不会登记 ID,允许重复。
  • 原本因重复隐藏的旧版 canvas 切成 2D 后也仍保持隐藏。
    我补了两个临时测试,分别覆盖 legacy → 2D 和 2D → legacy,两条都稳定失败。建议同时监听 canvasId 和 type;进入 2D 时释放登记并清除旧错误,进入 legacy 时重新执行完整 claim。
  1. [P1] 内部 moduleId 被泄漏进小程序事件的 target.dataset
    Canvas.vue#L129-L135
    events.js#L145-L150
    归属信息使用 data-canvas-owner 存在真实 DOM 上,而事件序列化会复制 event.target.dataset。因此点击或触摸 canvas 时,业务收到:
    event.target.dataset.canvasOwner === moduleId
    这不是开发者声明的数据,并且暴露了内部组件实例 ID。我通过真实 Canvas.vue 挂载并点击内部 canvas,临时回归用例稳定失败。
    建议使用非 data-* 的内部属性、Symbol/WeakMap,或者在事件序列化前剔除框架字段;同时补 tap/touch 载荷测试。

  2. [P1] 32 MB 限制仍不能约束导出峰值内存
    canvas/index.js#L54-L74
    runtime.js#L2582-L2635
    Android queue#L49-L59
    当前只把 native 解码/写盘改成串行,但还有两个未封口窗口:

  • destWidth、destHeight 只校验有限数,没有尺寸或像素面积上限。代码会先创建原始 RGBA outputCanvas 并同步执行 toDataURL(),可能在进入 native 32 MB 检查前就占用数百 MB并阻塞渲染线程。
  • message.invoke() 后任务立即结束,不等待 native complete。调用方可以快速生成并入队多份最大约 44 MB 的 Base64;串行队列中的每个闭包仍保留自己的字符串,十次调用即可保留约 440 MB。
    “拒绝时字符串已经生成”不能完全消除这个问题:拒绝后 native 桥接副本可以释放;也可以在 FE 侧等待前一次 complete 后再生成下一份,而不是改变循环导出的成功语义。
    建议在创建 outputCanvas 前做安全的宽高/像素面积检查,并增加 completion-aware 的 per-app 背压或有界排队测试。
  1. [P1] Android/iOS runtime 销毁后会留下无人可访问的大文件
    Android ImageApi.kt#L470-L483
    Android ImageApi.kt#L505-L513
    iOS ImageAPI.swift#L197-L219
    导出期间 exit/restart 后:
  • Android/iOS 后台任务继续解码并发布最终文件。
  • 回调会因旧 JsCore 或弱引用 container 失效而被丢弃。
  • 已发布文件没有返回给任何 runtime,也没有清理路径。
    Harmony 已通过 generation 校验删除这种文件,Android/iOS 尚未处理。尤其 iOS 的所谓 tmp 位于 Documents/Dimina,不是系统临时目录,不能依赖进程结束自动清理。
    这由本 PR 的异步化直接打开,不建议只留给未来的全局清扫。应给任务绑定 runtime owner/generation,失效时取消待执行任务或删除最终文件,并增加 exit/restart 竞态测试。

@lbb00
lbb00 marked this pull request as draft August 19, 2026 06:00
@lbb00

lbb00 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

第三轮四条和后续独立复审发现的问题都已闭环,最终修复在 a107f73

  1. type 切换破坏 canvas-id 登记

    desiredKey() 同时读取 canvasIdtype,由 watchEffect 追踪完整依赖。legacy → 2D 会释放 claim,2D → legacy 会重新 claim,原本因重复隐藏的节点转成 2D 后会恢复。三种路径都走真实 Canvas.vue 回归测试。

  2. moduleId 泄漏进 target.dataset

    owner 改为共享的非 data-* DOM property,组件和 runtime 不再各写私有字面量。tap 与 touch 载荷测试都断言小程序 dataset 只含开发者声明字段;render 中另一处归属写入也一并改掉。

  3. 导出峰值内存

    FE 在创建 output canvas、getImageData()Array.from()toDataURL() 前统一检查单边和像素面积;超限测试明确断言不会进入分配或编码。Android、iOS、HarmonyOS 的 registry 同时限制每 app 未结算请求数和累计 Base64 字节,第三个请求在持有 native bridge 副本前拒绝。

    这里没有采用“FE 等前一次 complete 才生成下一份”的方案。那会把合法的循环导出强制串行并延长总耗时;真正持有 bridge Base64 闭包、负责释放 native 内存的是三端 registry,因此把有界背压放在这一层。当前选择是最多 2 个未结算请求,超出时明确失败,不做无界排队。

  4. Android / iOS 销毁后残留文件

    三端任务都绑定 owner / generation。runtime 销毁先推进 generation、取消未开始任务;已经开始的任务继续计入预算直到终态,但不能向新 runtime 投递。若旧任务已经发布文件,结算点会删除文件。回归覆盖旧 runtime 导出拒绝交付,以及不可交付文件被删除。

本轮另外新增了两项实现与验证:

  • canvas touch 的 4 px 坐标偏移已修复。 组件化后 relativeTo 曾错用 border 内侧的真实 canvas,触摸坐标因此整体少 4 px;现在明确改为开发者声明的 canvas 根节点 border box,回归测试会在恢复旧基准时从 (40, 40) 精确失败为 (36, 36)。HarmonyOS Pura 90 emulator 的页面探针也确认:根节点原点为 (32, 134.5714),触点 client - (x, y) 得到同一组值,没有再落到内层 canvas 的 4 px 内缩上。
  • 新增新编译包统一走 dd-canvas 的实现。 根节点负责布局 / dataset / 事件,内部 canvas 负责 node 与绘图。runtime 同时保留旧编译包的原生 canvas 路径;旧包 + 新基础库实测可用,测试还覆盖页面/组件同名 id、组件根 canvas 和特殊字符 id。

在此之后又做了一轮完整独立 review,继续修了 layout → backing store 预算绕过、动态 resolver、小游戏替换后的 render 资源残留、callback / RAF / ImageData 生命周期、iOS Unicode Base64 预算绕过,以及 Canvas2D setter/readback、save/restore/reset/resize 和旧 WebKit fallback。最终独立复查没有留下未闭环 finding。

验证:

  • FE 1683 个用例全部实际通过;lint、production build、diff-check 通过。
  • Android 261,iOS 279,全部 0 失败。
  • HarmonyOS unit task 和 HAP build 通过。
  • Android 35 Pixel 7 AVD、iOS Simulator、HarmonyOS 6.1.1 / API 24 Pura 90 emulator 的 legacy Canvas 页面链路各 59/59。
  • 最终 service bundle 已由固定 QuickJS 加载,SDK / JSApp 归档与最终 dist 一致。

@lbb00
lbb00 force-pushed the worktree-canvas-peripherals-pr branch 4 times, most recently from 7db3af6 to 9d08111 Compare August 26, 2026 03:15
@lbb00

lbb00 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

上一条之后又做了几轮独立复审,修复已经 amend 进同一个 commit(现为 9d08111),描述同步更新。

1. 多指下的 tap 判定

原实现在第二根手指按下时立刻抑制 tap 并派发 canceltap。这个时机微信公开文档没有定义,用探针小程序在微信开发者工具上实测:第一根手指原地抬起仍然产生 tap。现在多指只取消长按计时器(longpress / longtap 是单指手势),不重置起点,也不影响先落下那根手指的判定。

同时明确不采用 glass-easel 按 identifier 逐触点记 possibleTaps 的模型:一个手势所有者只维护一条触摸序列,多指同时按下时只有建立序列的那根手指产生 tap / canceltap,其余触点只透传原生 touchstart / touchmove / touchend。这条差异写进了触摸事件与手势 §7

2. canceltap 的条数与 touchend 终点复查

同一个探针实测:越过位移阈值后,之后每一条 touchmove 都各带一条 canceltap,不是整段序列只发一条。另外 touchmove 可能一条都不到(被 preventDefault 吞掉,或系统把移动并进抬手),因此 touchend 会按起点与终点再判一次距离,超过阈值改发 canceltap 而不合成 taptouchend 复查与 touchcancel 这两条补发路径只在整段序列还没发过 canceltap 时生效。回归测试断言序列为 ['touchmove', 'canceltap', 'touchmove', 'canceltap']

3. 节点卸载后仍会补造长按

longpress / longtap 由所有者自己的计时器合成。卸载时若原生序列仍在进行,所有者会延迟到真实 touchend / touchcancel 再摘除(不伪造终态),但此前没有停表,节点离开树之后仍可能收到一次 longpress。现在卸载路径带 nodeRemoved 标记并立即清计时器;bind / catch 变化引起的换 owner 不带这个标记,节点仍在树上,长按照常触发。新增 gesture-detach-on-unmount.spec.js 覆盖这两条路径。

4. HarmonyOS 编译回归

本 PR 给 DMPContainerBridgesModule 加了 protected currentAppId(),而 +Image+Video+ScreenNetwork/+Http 四个子类各有一份逐字相同的 private currentAppId()。ArkTS 不允许子类收窄继承成员的可见性,dimina:default@UnitTestArkTS 报 9 个 10505001。已删掉四处重复实现,统一继承基类。

5. JSSDK 重新生成

上面的手势改动落在 fe/packages/components 之后,shared/jssdk/main.zip 一度落后于源码。现已用 pnpm build + pnpm generate:sdk 从 production dist 重新生成,包内 service.jspageFrame.jsfe/packages/container/dist/assets 逐字节一致;config.jsonversionCode 也复位成基于 main 的 33,不再带本地反复生成累积的计数。

文档同步:触摸事件与手势第 1、3、6、7 节按上述语义重写;Canvas 运行架构补充了 draw(reserve) 只决定像素去留、不决定绘图状态的说明。

@lbb00
lbb00 force-pushed the worktree-canvas-peripherals-pr branch from 9d08111 to 3cbf926 Compare August 26, 2026 03:46
@lbb00
lbb00 marked this pull request as ready for review August 26, 2026 04:07
@dos1in

dos1in commented Aug 26, 2026

Copy link
Copy Markdown
Member
<view bindtap="outer">
  <custom-component catchtap="inner" />
</view>

抬手时祖先的合成 tap 已经派发;稍后的自定义组件 click 即使调用 stopPropagation(),也无法阻止之前那条触摸事件对应的祖先 tap。这是事件冒泡语义回退。
建议让自定义组件宿主也进入统一的 attachTouchEvents/停止标记链路,并增加 bindtap → custom component catchtap 的 touch 和 pointer 回归用例。

  • [P2] canvasToTempFilePath 把合法的 quality: 0 改成了 1
    runtime.js#L2885-L2894 使用 numericQuality <= 0 回退到 1。但 JPEG quality 的合法范围包含 0;调用方显式传入最低质量时,实际会以最高/默认质量导出,文件大小和画质均不符合参数。
    应将判断改为 < 0,并补充断言 toDataURL('image/jpeg', 0) 的测试。

旧版 wx.createCanvasContext() 补齐三端能力。canvas-id 按「页面 + 自定义组件实例」登记,
归属与 active 状态走 DOM property,不进入小程序事件的 target.dataset。组件根节点承载小程序
声明的 id、dataset 和布局,内部 HTMLCanvasElement 承载绘图,两者用共享的 DOM contract 显式
关联,SelectorQuery 的 node 不会误取 slot 里的 canvas。

绘制、像素读写与导出按真实 canvas 元素串行,不同 canvas 互不阻塞;作用域内的节点 lookup 单独
排队,避免后发请求越过还在等节点挂载的先发请求。Canvas 2D 状态以渲染层宿主 context 为权威,
逻辑层提供同步 optimistic getter 并以单调序号接收 readback,迟到反馈不覆盖新值;宿主缺少
reset() 时重设同尺寸 backing store 完成全量重置。

位图与 JSON 传输预算收拢到 @dimina/common 一处,组件 renderWidth/renderHeight、布局同步、
node width/height setter、离屏与小游戏创建、legacy 像素 API 和 canvasToTempFilePath 共用同一
组限制,超限在分配输出位图与编码之前失败。

Android、iOS、HarmonyOS 的导出请求绑定 app owner 与 runtime generation:退出或重启先推进
generation 并取消尚未开始的请求,已开始的继续占用并发与字节配额,旧 generation 的结果不投递、
已发布的文件立即删除,success/fail 与 complete 在同一串行域结算。

draw(reserve) 只保留像素、不保留绘图状态:渲染层每批开一层基线 save 帧,下批开始时弹回默认
样式、变换与裁剪区,画面不动;批内未配平的 restore() 停在批边界,弹不走基线帧。

触摸手势统一由 touchGestures 合成 tap/longpress/longtap/canceltap,按 owner 记账触点,不再
依赖全局 event.touches;label 默认不抑制内层组件的激活态。取消语义按微信实测对齐:越过位移
阈值后每条 touchmove 各发一条 canceltap,touchend 再比一次起终点距离、超阈值改发 canceltap,
这两条补发路径只在整段序列还没发过 canceltap 时生效;额外触点只停长按计时器,不取消先落手指
的 tap。节点卸载时进行中的序列仍等真实终态收口,但立即停掉长按计时器,不给已离树的节点补造
longpress/longtap。一个手势所有者只维护一条序列,不采用 glass-easel 按 identifier 逐触点
记账的模型。自定义组件的宿主节点接入同一条链路,由 ComponentHost 用 useTouchEvents 安装,不再
靠原生 click 派发 tap —— click 排在祖先的合成 tap 之后,宿主上的 catchtap 拦不住祖先的 bindtap。

DMPContainerBridgesModule 上移 currentAppId() 为 protected,Image/Video/Screen/Http 四个
子类删除各自逐字相同的 private 实现:ArkTS 不允许子类收窄继承成员的可见性。

新增 docs/canvas-architecture.md、docs/touch-event-architecture.md 记录上述不变量。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lbb00
lbb00 force-pushed the worktree-canvas-peripherals-pr branch from 3cbf926 to b6b6fce Compare August 26, 2026 06:23
@lbb00

lbb00 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

P1 已修

比你说的还严重一点:合成 tap 那套会直接忽略真指针产生的 click(detail > 0 就 return),所以宿主的 tap 不只是排在祖先后面,两条路径根本不共享同一个原生事件,停止标记对不上。反过来,组件内部的 catchtap 也切不断宿主自己的 bindtap

改法是让宿主节点直接用 useTouchEventsruntime.js 里那段 addEventListener('click') 删掉。编译器给每个组件模板都包了一层专属宿主元素(新包 component-host、旧包 dd-wrapper,指向同一个组件),所以不会和 view 抢 owner,旧编译包也一起修好了。

新增 component-host-gesture.spec.js 5 条用例:touch / pointer 下宿主 catchtap 拦住祖先、bindtap 的冒泡顺序、组件内部 catchtap 拦住宿主、程序化 click 仍能激活。把实现换回 click 派发,正好那 3 条依赖合成 tap 的用例失败。

P2 没改

官方类型写的是取值范围为 (0, 1],不在范围内时当作 1.0 处理,开区间,0 不在范围内。改成 < 0 会导出一张最低质量的图,反而不符合小程序语义。补了 canvas-export-quality.spec.js 把这条锁住。(Canvas node 的 canvas.toDataURL 是浏览器 API,那边继续原样透传。)

另外提一句:component-host-structure.spec.js 里原有的 not.toMatch(/\bref=/) 会挡住宿主拿 ref,换成了直接断言 not.toContain('shadowRoot'),「不创建 ShadowRoot」的原意保留。

@lbb00
lbb00 marked this pull request as draft August 26, 2026 07:08
@lbb00
lbb00 marked this pull request as ready for review August 26, 2026 07:23
@lbb00

lbb00 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@dos1in 辛苦再次 Review

@dos1in

dos1in commented Aug 26, 2026

Copy link
Copy Markdown
Member

问题摘要

级别 问题 影响范围
P1 iOS CI 固定依赖 iPhone 16 Simulator iOS CI
P1 Canvas 导出队列按 runtime generation/owner 拆分,无法保证同 app 串行 Android、iOS、Harmony
P2 Canvas getImageData() 在 context 不存在时永久 pending FE Render、Service
P2 Image 同时使用自定义和浏览器原生懒加载,lazy-load-margin 不再具有确定性 FE Image 组件

详细问题

[P1] iOS CI 固定依赖 iPhone 16 Simulator

这个问题主分支已经改过了,应该是合并代码出问题了。

位置:.github/workflows/ios-tests.yml:41

PR 删除了原有的 Simulator 自动选择或创建逻辑,改为固定使用:

-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest'

当 Runner 没有安装该机型时,xcodebuild 会直接报错:

Unable to find a device matching the provided destination specifier

当前 CI 通过只能说明当前 Runner 恰好存在该设备,不能证明该配置可以在其他 Xcode 或 Runner 镜像上稳定执行。

建议:

  1. 恢复动态查找可用 iPhone Simulator 的逻辑。
  2. 没有可用设备时,根据已安装 runtime 创建 Simulator。
  3. 最终通过 Simulator UDID 传递 -destination,避免依赖具体机型名称。

[P1] 三端 Canvas 导出没有真正做到同 app 串行

相关位置:

本次实现通过限制单次解码大小和导出并发数控制内存峰值,并在注释及架构文档中要求“同一个小程序同时只做一次解码和写盘”。但实际串行队列按 runtime 生命周期进行了拆分:

  • Android 的 Mutex 按 generation 存储。
  • iOS 的 DispatchQueue 按 appId + generation 创建。
  • Harmony 的 Promise 队列使用 appId:owner 作为 key。

当旧 runtime 的导出已经开始时,销毁流程会保留该任务;新 runtime 随后仍可以预留剩余名额,并获得不同的锁或队列。此时同一个 app 的旧、新任务可以同时解码和写盘。

典型时序:

  1. 旧 runtime 的任务进入解码/写盘临界区。
  2. runtime 被销毁并重新创建。
  3. 已排队但尚未开始的旧任务被取消,正在执行的旧任务继续运行。
  4. 新 runtime 预留空出的名额。
  5. 新任务使用另一条 generation/owner 队列,与旧任务并发执行。

这会突破本次 PR 用来防止 OOM 的核心约束。现有测试只验证了旧任务继续计数以及新任务可以预留,没有真正启动新任务并断言其必须等待旧任务结束。

建议:

  1. Android Mutex、iOS DispatchQueue、Harmony Promise 队列均只按 appId 复用。
  2. generation/owner 只负责取消排队任务、抑制过期回调和执行生命周期清理。
  3. 三端增加跨 generation/owner 测试:让旧任务占住临界区,重启 runtime 后启动新任务,断言新任务在旧任务释放前不能进入临界区。

[P2] Canvas getImageData() 可能永久 pending

相关位置:

Service 侧调用 getImageData() 时会创建 Promise,并等待 Render 侧执行 callback。Render 侧当前只有在成功找到 context 时才触发 callback;如果宿主 Canvas 创建 context 失败并返回 null,分支会直接结束。

最终结果是:

  • Promise 不 resolve;
  • Promise 不 reject;
  • callback 无法释放。

建议:

  1. context 不存在时回调 { ok: false, error: 'canvas context not found' }
  2. 增加宿主 Canvas getContext() 返回 null 的测试,断言 Promise 会 reject 且 callback 被释放。

[P2] Image 组件存在两层独立懒加载门控

这个问题主分支也已经改过了,按主分支逻辑走就行。

位置:Image.vue:151Image.vue:243

组件已经通过 IntersectionObserverlazy-load-margin 控制何时把 URL 写入 renderedSrc,但 <img> 又设置了浏览器原生 loading="lazy"

rendering-mode="img" 下,即使自定义 Observer 已经按配置释放 src,浏览器仍可能根据自身阈值继续推迟网络请求。因此 lazy-load-margin 无法准确控制请求开始时间。

建议保留自定义 IntersectionObserver 作为唯一门控:当 src 被释放后,让 <img> 使用 loading="eager"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants